#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Utility for working with Philips Xenium mobile phones via serial port.
#
# Copyright (C) 2010  Alexander Artamoshin <artamoshin@gmail.com>
# http://code.google.com/p/xeniumtool/
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
#
# pySerial module:
# http://pyserial.sourceforge.net/
#
# argparse: Python command line parser
# http://code.google.com/p/argparse/

# TODO: photos
# TODO: code refactoring
# TODO: GUI

from __future__ import with_statement # for Python 2.5

__version__ = '0.2.2'

import sys
import os
import re
from cStringIO import StringIO

if sys.platform == 'win32':
    import win32_unicode_argv # command line unicode support in Windows

import serial
import argparse

CHUNK_SIZE = 200 # block length of raw data for writing to mobile

# File types attributes:
RTF_ATTR_READ_ONLY = 0x01
RTF_ATTR_HIDDEN    = 0x02
RTF_ATTR_SYSTEM    = 0x04
RTF_ATTR_VOLUME    = 0x08
RTF_ATTR_DIR       = 0x10
RTF_ATTR_ARCHIVE   = 0x20

# Battery connection status
BATTERY_STATUS = ("Is powered by the battery",
                  "Has a battery connected, but is not powered by it",
                  "Does not have a battery connected",
                  "Recognized power fault, calls inhibited")

CONTACT_KEYS = ('index', 'name', 'mobile', 'home', 'business',
                'fax', 'email', 'company', 'group', 'birthday')

CONTACT_GROUPS = ('Family', 'Work', 'Friend', 'Office', 'Other')

JAVA_BLOCK_SIZE = 256 # size of item in suite.idx file

APPS_RIGHTS_OPTIONS = {'network':      0x16,
                       'invocation':   0x1A,
                       'messaging1':   0x1E,
                       'messaging2':   0x32,
                       'multimedia':   0x22,
                       'read':         0x26,
                       'write':        0x2A,
                       'connectivity': 0x2E}

APPS_RIGHTS_VALUES = {'promt_always': '\x04',
                      'promt_first':  '\x02',
                      'promt_once':   '\x01',
                      'allow':        '\x11',
                      'never':        '\x08'}

def filemode(func):
    "Decorator wraps methods that working with files."
    def wrapper(self, *args, **kwargs):
        "Helps setting modes."
        self.cmd('AT+ESUO=3') # set mode required for files operation
        response = func(self, *args, **kwargs)
        self.cmd('AT+ESUO=4') # setting normal mode for contacts, etc.
        return response
    return wrapper

class XeniumControl(object):
    """Class for working with Philips Xenium mobiles.
    Currently only X501 (V13) are tested."""
    def __init__(self, port=None, baudrate=115200, debug=False):
        self.debug = debug
        if port is None: # trying autodetect port
            port = self._scan()
            if not port:
                raise IOError, "Mobile phone not found"
        self.serial = serial.Serial(port, baudrate=baudrate, timeout=1)
        self._set_echo(False)

    def __del__(self):
        if hasattr(self, 'serial'):
            self.serial.close()

    def _set_echo(self, value):
        """Determines whether or not the DCE echoes characters received from
        the DTE during command state. Value must be boolean."""
        return self.cmd('ATE%d' % int(value))

    @staticmethod
    def _scan():
        """Trying to detect a phone which connected to serial port.
        Returns a port number or False if failed."""
        if sys.platform == 'linux2':
            import glob
            ports = sorted(glob.glob('/dev/ttyS*'))
            ports += sorted(glob.glob('/dev/ttyUSB*'))
        else:
            ports = range(256)
        for port in ports:
            try:
                ser = serial.Serial(port, timeout=0.1)
                ser.write('AT+CGMI\r\n')
                answer = ser.read(128)
                if '+CGMI: Philips X' in answer: # TODO: improve (i.e. ERROR)
                    ser.close()
                    return port
                ser.close()
            except serial.serialutil.SerialException:
                pass
        return False

    # I/O methods

    def _write(self, cmd):
        "Sends a command to a mobile phone."
        if self.debug:
            sys.stderr.write('> %s\n' % repr(cmd))
        self.serial.write(cmd)
        self.serial.write('\r\n')

    def _read(self):
        """Reads answer and returns stripped buffer if OK
        (True if result is empty) or False if ERROR"""
        buff = ''
        while True:
            size = self.serial.inWaiting()
            buff += self.serial.read(size)
            if '\r\nOK\r\n' in buff:
                result = buff.lstrip('\r\n').rstrip('\r\nOK\r\n')
                if result == '':
                    result = True
                break
            if '\r\nERROR\r\n' in buff:
                result = False
                break
        if self.debug:
            sys.stderr.write('< %s\n' % repr(buff))
        return result

    def cmd(self, cmd):
        "Sends the command to mobile and returns answer."
        self._write(cmd)
        return self._read()

    # Summary

    def get_manufacturer(self):
        "Returns the manufacturer of the phone."
        return self.cmd('AT+CGMI').split(': ')[1]

    def get_model(self):
        "Returns information about model."
        return self.cmd('AT+CGMM').split(': ')[1]

    def get_revision(self):
        "Returns information regarding software version."
        return self.cmd('AT+CGMR').split(': ')[1]

    def get_imei(self):
        "Returns the IMEI number of the phone."
        return self.cmd('AT+CGSN')

    def get_battery(self):
        "Returns battery connection status and battery level in percents."
        bcs, bcl = self.cmd('AT+CBC').split(': ')[1].split(', ')
        return int(bcs), int(bcl)

    def get_rssi(self):
        "Returns Received Signal Strength Indication in dBm."
        answer = self.cmd('AT+CSQ')
        rssi = int(answer.split(': ')[1].split(', ')[0])
        return -113 + rssi * 2

    # Keyboard

    def send_keys(self, keys):
        """Emulates keypad by setting each keystrokes a character in a string.
        Char  Comment
        [     left soft key
        ]     right soft key
        M     menu
        ^     up arrow
        V     down arrow
        <     left arrow
        >     right arrow
        S     connection start
        E     connection end
        0..9  number keys
        *     star
        #     hash
        D     volume down
        U     volume up
        F     function (camera)"""
        return self.cmd('AT+CKPD="%s"' % keys)

    # Contacts

    def _get_total_contacts(self):
        "Returns contacts book size."
        answer = self.cmd('AT+PPBR=?')
        match = re.search(r'\((?P<count>\d+),(?P<total>\d+)\)', answer)
        return int(match.group('count')), int(match.group('total'))

    def get_contact(self, index):
        "Returns phone contact by index as dictionary."
        answer = self.cmd('AT+PPBR=%d' % index)
        if type(answer) is str:
            answer = unicode(answer, 'utf8')
            data = [x[1:-1] for x in answer.lstrip('+PPBR:').split(',')]
            return dict(zip(CONTACT_KEYS, data))
        else:
            return None # phone field is empty

    def get_contacts(self):
        "Returns a list of all contacts as dictionaries."
        # TODO: progressbar
        count, total = self._get_total_contacts()
        readed = 0
        for i in xrange(total):
            contact = self.get_contact(i)
            if not contact is None:
                yield contact
                readed += 1
                if readed == count:
                    return

    def add_contact(self, contact_dict):
        """Adds a contact to phone internal storage. A dictionary may has
        following optional keys: index, name, mobile, home, business, fax,
        email, company, group [0..5], birthday('YYYY/MM/DD'). If lenght of
        field is over than permissible field will be trimmed by phone."""
        attributes = []
        for key in CONTACT_KEYS:
            if contact_dict.has_key(key) and not contact_dict[key] is None:
                # protocol has not escape character for quotes
                attributes.append(unicode(contact_dict[key]).replace('"', '_'))
            else:
                if key == 'index' or key == 'group':
                    attributes.append(0) # setting default value
                else:
                    attributes.append('')
        command = u'AT+PPBW=' + u', '.join(['"%s"' % x for x in attributes])
        return self.cmd(command.encode('utf8'))

    def del_contact(self, index):
        "Deletes phone contact by index."
        return self.cmd('AT+PPBW=%d' % index)

    def wipe_contacts(self):
        "Deletes all phone contacts."
        # TODO: progressbar
        total = self._get_total_contacts()[1]
        return total == sum((self.del_contact(i) for i in xrange(total)))

    # Files

    @staticmethod
    def _encode_filename(string):
        "Encodes path string to UCS2 and return the hexadecimal representation."
        return string.encode('utf-16-be').encode('hex')

    @staticmethod
    def _decode_filename(string):
        "Decodes hexadecimal representation of path string."
        return string.decode('hex').decode('utf-16-be')

    @filemode
    def get_drives(self):
        "Returns a list of available drives."
        drives = []
        for char in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
            drive = '%s:\\' % char
            answer = self.cmd('AT+EFSL="%s"' % self._encode_filename(drive))
            if answer:
                drives.append(char)
        return drives

    @filemode
    def get_free_space(self, drive):
        "Returns the available size on the file system in bytes."
        answer = self.cmd('AT+EFS=%d' % ord(drive))
        return int(answer.split(': ')[1])

    @filemode
    def get_files_list(self, directory):
        """Returns a list of tuples in format (str filename, int filesize,
        int attribute)."""
        files = []
        answer = self.cmd('AT+EFSL="%s"' % self._encode_filename(directory))
        if not answer:
            return False
        for line in answer.split('\r\n\r\n'):
            item = line.split(': ')[1].split(', ')
            filename = self._decode_filename(item[0][1:-1])
            filesize = int(item[1])
            attribute = int(item[2])
            files.append((filename, filesize, attribute))
        return files

    @filemode
    def read_file(self, mobile_path, pc_file):
        "Copies file mobile_path to pc_file."
        # TODO: speed up
        # TODO: progressbar
        answer = self.cmd('AT+EFSR="%s"' % self._encode_filename(mobile_path))
        if answer:
            for line in answer.split('\r\n\r\n'):
                item = line.split(': ')[1].split(', ')
                eof = int(item[1])
                length = int(item[2])
                if length: # length may be 0
                    data = item[3][1:-1].decode('hex')
                    pc_file.write(data)
            return bool(eof)

    @filemode
    def delete_file(self, mobile_path):
        "Deletes file from mobile."
        return self.cmd('AT+EFSD="%s"' % self._encode_filename(mobile_path))

    @filemode
    def write_file(self, pc_file, mobile_path):
        "Copies file pc_file to mobile_path."
        # TODO: progressbar
        # overwrite
        self.cmd('AT+EFSD="%s"' % self._encode_filename(mobile_path))
        self.cmd('AT+EFSW=0,"%s"' % self._encode_filename(mobile_path))
        while True:
            data = pc_file.read(CHUNK_SIZE)
            if not data:
                break
            # TODO: EOF marker
            self.cmd('AT+EFSW=2,0,%d,"%s"' % (len(data), data.encode('hex')))
        self.cmd('AT+EFSW=1')
        return True

    @filemode
    def make_directory(self, mobile_path):
        "Makes new directory."
        return self.cmd('AT+EFSF=0,"%s"' % self._encode_filename(mobile_path))

    @filemode
    def remove_directory(self, mobile_path):
        "Removes directory."
        return self.cmd('AT+EFSF=1,"%s"' % self._encode_filename(mobile_path))

    @filemode
    def move_file(self, old_path, new_path):
        """Moves/rename file from old_path to new_path.
        Working only on same disk."""
        self.cmd('AT+EFSRN=1,"%s"' % self._encode_filename(old_path))
        self.cmd('AT+EFSRN=2,"%s"' % self._encode_filename(new_path))
        return self.cmd('AT+EFSRN=0')

    @filemode
    def get_tree(self, mobile_path, pc_path):
        "Copies directory mobile_path to pc_path with subdirs."
        files = self.get_files_list(mobile_path)
        if files:
            for filename, size, attribute in files:
                if filename != '.' and filename != '..':
                    print os.path.join(mobile_path, filename)
                    new_pc_path = os.path.join(pc_path, filename)
                    new_mobile_path = os.path.join(mobile_path, filename)
                    if attribute & RTF_ATTR_DIR == RTF_ATTR_DIR:
                        if not os.path.exists(new_pc_path):
                            os.makedirs(new_pc_path)
                        self.get_tree(new_mobile_path, new_pc_path)
                    else:
                        with open(new_pc_path, 'wb') as pc_file:
                            self.read_file(new_mobile_path, pc_file)

    def get_apps_list(self):
        stream = StringIO()
        self.read_file('D:\\@Java\\suite.idx', stream)
        suite = stream.getvalue()
        stream.close()

        blocks = []
        for offset in xrange(0, len(suite), JAVA_BLOCK_SIZE):
            blocks.append(suite[offset:offset+JAVA_BLOCK_SIZE])

        if blocks[0][25:34] != '\x00\x04VERSION':
            raise IOError, "Unsupported suite.idx file"

        for i, block in enumerate(blocks[1:]):
            title = block[1:64].rstrip('\x00')
            path = block[205:238].rstrip('\x00')
            if title and path:
                yield (int((os.path.split(path)[1]).split('.')[0]), title)

    def _get_app_mss(self, app_index):
        "Returns data of mss file by application index."
        stream = StringIO()
        if self.read_file('D:\\@Java\\%d.mss' % app_index, stream):
            data = stream.getvalue()
            stream.close()
        else:
            raise IOError, "Invalid application index"
        return data

    def get_apps_rights(self, app_index):
        mss = self._get_app_mss(app_index)
        options_dict = {}
        for option in APPS_RIGHTS_OPTIONS:
            offset = APPS_RIGHTS_OPTIONS[option]
            value = mss[offset]
            options_dict[option] = [k for (k, v) in APPS_RIGHTS_VALUES.iteritems()
                           if v == value][0] # searching key in dict by value
        return options_dict

    def set_apps_rights(self, app_index, options_dict):
        mss = self._get_app_mss(app_index)
        for option in options_dict:
            offset = APPS_RIGHTS_OPTIONS[option]
            value = APPS_RIGHTS_VALUES[options_dict[option]]
            mss = mss[:offset] + value + mss[offset+1:]

        stream = StringIO(mss)
        self.write_file(stream, 'D:\\@Java\\%d.mss' % app_index)
        stream.close()


class _MySubParsersAction(argparse._SubParsersAction):
    def add_command(self, name, **kwargs):
        "Wraps add_parser with automatically setting action."
        subparser = self.add_parser(name, **kwargs)
        subparser.set_defaults(action=name)
        return subparser


class CommandsParser(argparse.ArgumentParser):
    def __init__(self, *args, **kwargs):
        super(CommandsParser, self).__init__(*args, **kwargs)
        self.register('action', 'parsers', _MySubParsersAction)


def parse_command_line():
    notice = """xeniumtool %s
    Copyright (C) 2010  Alexander Artamoshin <artamoshin@gmail.com>
    http://code.google.com/p/xeniumtool/
    This program comes with ABSOLUTELY NO WARRANTY.
    This is free software, and you are welcome to redistribute it
    under certain conditions. See the file COPYING for details.""" % __version__

    parser = CommandsParser(description=notice)
    parser.add_argument('-p', '--port', default=None,
                        help="device name or port number "\
                             "(default: autodetect)")
    parser.add_argument('-b', '--baudrate', default=115200, type=int,
                        help="baud rate (default: 115200)")
    parser.add_argument('-d', '--debug', action='store_true', default=False,
                        help="print communication messages")

    subparsers = parser.add_subparsers(title='commands')

    parser_cmd = subparsers.add_command('cmd',
        help="send user defined AT-command")
    parser_cmd.add_argument('command', type=unicode)

    parser_info = subparsers.add_command('info', help="show phone's summary")

    parser_sendkeys = subparsers.add_command('sendkeys',
        help="emulates keypad by setting each keystrokes "
             "a character in a string")
    parser_sendkeys.add_argument('keys', type=unicode,
        help="see http://code.google.com/p/xeniumtool/wiki/sendkeys")

    parser_listcontacts = subparsers.add_command('listcontacts',
        help="print a list of all contacts")

    parser_showcontact = subparsers.add_command('showcontact',
        help="show detailed info about contact")
    parser_showcontact.add_argument('index', type=int)

    parser_addcontact = subparsers.add_command('addcontact',
        help="add a contact to phone internal storage")
    for key in CONTACT_KEYS:
        required = key == 'name'
        if key == 'index' or key == 'group':
            field_type = int
        else:
            field_type = unicode
        parser_addcontact.add_argument('--%s' % key, type=field_type,
                                       required=required)

    parser_delcontact = subparsers.add_command('delcontact',
        help="delete phone contact by index")
    parser_delcontact.add_argument('index', type=int)

    parser_wipecontacts = subparsers.add_command('wipecontacts',
        help="delete all phone contacts (!)")

    if sys.platform == 'win32':
        parser_import = subparsers.add_command('import',
            help="import contacts from MS Outlook",
            description="Fields mapping: set Outlook field as parameter for "
                        "phone's field (i.e. --name=LastName). Groups mapping: "
                        "set Outlook category name for mobile group (i.e. "
                        "--group3=Friends). One tag or space-separated list "
                        "(--group2=\"Work Company\") allowed.")
        defaults = ('LastName FirstName NickName',
                    'MobileTelephoneNumber',
                    'HomeTelephoneNumber',
                    'BusinessTelephoneNumber',
                    'BusinessFaxNumber',
                    'Email1Address',
                    'CompanyName')
        for key, default in zip(CONTACT_KEYS[1:8], defaults):
            parser_import.add_argument('--%s' % key, type=unicode,
                default=default, help="default: %s" % default)
        for i in xrange(5):
            parser_import.add_argument('--group%d' % (i + 1), type=unicode,
                default=CONTACT_GROUPS[i], help="default: %s" % CONTACT_GROUPS[i])

    parser_df = subparsers.add_command('df',
        help="list of drives and available space "
             "(X501: wrong storage card size)")

    parser_ls = subparsers.add_command('ls',
        help="list files in directory on the phone")
    parser_ls.add_argument('dir', type=unicode)

    parser_rm = subparsers.add_command('rm', help="delete file from mobile")
    parser_rm.add_argument('mobile_path', type=unicode)

    parser_read = subparsers.add_command('read',
        help="copy file mobile_path to pc_path")
    parser_read.add_argument('mobile_path', type=unicode)
    parser_read.add_argument('pc_file', type=argparse.FileType('w'))

    parser_write = subparsers.add_command('write',
        help="copy file pc_file to mobile_path")
    parser_write.add_argument('pc_file', type=argparse.FileType('r'))
    parser_write.add_argument('mobile_path', type=unicode)

    parser_mkdir = subparsers.add_command('mkdir', help="make new directory")
    parser_mkdir.add_argument('mobile_path', type=unicode)

    parser_rmdir = subparsers.add_command('rmdir', help="remove directory")
    parser_rmdir.add_argument('mobile_path', type=unicode)

    parser_mv = subparsers.add_command('mv',
        help="move/rename file from old_path to new_path")
    parser_mv.add_argument('old_path', type=unicode)
    parser_mv.add_argument('new_path', type=unicode)

    parser_gettree = subparsers.add_command('gettree',
        help="copy directory mobile_path to pc_path with subdirs")
    parser_gettree.add_argument('mobile_path', type=unicode)
    parser_gettree.add_argument('pc_path', type=unicode)

    parser_listapps = subparsers.add_command('listapps',
        help="list installed Java applications")

    parser_setrights = subparsers.add_command('setrights',
        help="set given rights for application (including preinstalled)",
        description="Available values: %s" % ', '.join(sorted(APPS_RIGHTS_VALUES)))
    parser_setrights.add_argument('app_index', type=int,
        help="application index (use \"listapps\")")
    for option in sorted(APPS_RIGHTS_OPTIONS):
        parser_setrights.add_argument('--%s' % option, type=unicode)

    return parser.parse_args()

def main():
    args = parse_command_line()

    # TODO: fix UnicodeDecodeError in serialwin32.py: raise SerialException...
    try:
        mobile = XeniumControl(args.port, args.baudrate, args.debug)
    except (serial.SerialException, IOError), e:
        print e
        sys.exit()

    if args.action == 'cmd':
        print mobile.cmd(args.command) # TODO: improve answer (OK/ERROR/etc.)

    elif args.action == 'info':
        battery = mobile.get_battery()
        print "Model:    %s" % mobile.get_model()
        print "Software: %s" % mobile.get_revision()
        print "IMEI:     %s" % mobile.get_imei()
        print "Battery:  %d%% (%s)" % (battery[1], BATTERY_STATUS[battery[0]])
        print "Signal:   %d dBm" % mobile.get_rssi()

    elif args.action == 'sendkeys':
        print mobile.send_keys(args.keys)

    elif args.action == 'listcontacts':
        for contact in mobile.get_contacts():
            print "%4d: %s" % (int(contact['index']), contact['name'])

    elif args.action == 'showcontact':
        contact = mobile.get_contact(args.index)
        for key in CONTACT_KEYS:
            print (key.capitalize() + ':').ljust(9), contact[key]

    elif args.action == 'addcontact':
        contact = dict([(key, getattr(args, key)) for key in CONTACT_KEYS])
        mobile.add_contact(contact)

    elif args.action == 'delcontact':
        print mobile.del_contact(args.index)

    elif args.action == 'wipecontacts':
        print mobile.wipe_contacts()

    elif args.action == 'import':
        import outlook_importer
        keys = CONTACT_KEYS[1:8]
        fields_mapping = dict([(key, getattr(args, key)) for key in keys])
        groups_mapping = [getattr(args, 'group%s' % i) for i in xrange(1, 6)]

        for contact in outlook_importer.get_outlook_contacts(fields_mapping,
                                                             groups_mapping):
            try:
                print contact['name']
            except UnicodeEncodeError, e:
                print e
            mobile.add_contact(contact)

    elif args.action == 'df':
        for drive in mobile.get_drives():
            print "%s: %.2f MiB" % (drive,
                                    mobile.get_free_space(drive) / 1048576.0)

    elif args.action == 'ls':
        files = mobile.get_files_list(args.dir)
        if files:
            for filename, size, attribute in files:
                if attribute & RTF_ATTR_DIR == RTF_ATTR_DIR:
                    attribute, size = '<DIR>', ''
                else:
                    attribute = ''
                print "%s\t%s\t%s" % (attribute, size, filename)
        else:
            print 'Invalid directory'

    elif args.action == 'rm':
        print mobile.delete_file(args.mobile_path)

    elif args.action == 'read':
        print mobile.read_file(args.mobile_path, args.pc_file)

    elif args.action == 'write':
        print mobile.write_file(args.pc_file, args.mobile_path)

    elif args.action == 'mkdir':
        print mobile.make_directory(args.mobile_path)

    elif args.action == 'rmdir':
        print mobile.remove_directory(args.mobile_path)

    elif args.action == 'mv':
        print mobile.move_file(args.old_path, args.new_path)

    elif args.action == 'gettree':
        mobile.get_tree(args.mobile_path, args.pc_path)

    elif args.action == 'listapps':
        for index, name in mobile.get_apps_list():
            print "%2d: %s" % (index, name)
            rights_dict = mobile.get_apps_rights(index)
            for option in sorted(rights_dict):
                print "      %s: %s" % (option, rights_dict[option])

    elif args.action == 'setrights':
        options_dict = {}
        for option in APPS_RIGHTS_OPTIONS:
            value = getattr(args, option)
            if not value is None:
                options_dict[option] = value
        mobile.set_apps_rights(args.app_index, options_dict)

if __name__ == '__main__':
    main()
